在 Vue 3 的世界裡,Composition API 為我們帶來了更靈活、更強大的組件編寫方式。而 @vueuse/core
和自定義 Composables 則是在這基礎上,進一步提升我們的開發效率和代碼質量的利器。今天,我們將深入探討如何運用這些工具,讓我們的 Vue 3 + TypeScript 開發更加高效和優雅。
@vueuse/core
是一個基於 Composition API 的實用函數集合,它提供了大量常用的邏輯封裝,幫助我們快速實現各種功能,從而減少重複編碼的工作。
首先,讓我們安裝 @vueuse/core
:
bun add @vueuse/core
現在,讓我們通過幾個例子來看看 @vueuse/core
如何提升我們的開發效率。
useLocalStorage
import { ref, computed } from 'vue'
import { useLocalStorage } from '@vueuse/core'
const count = useLocalStorage('count', 0)
const doubleCount = computed<number>(() => count.value * 2)
const increment = () => {
count.value++;
}
這個例子展示了如何使用 useLocalStorage
來持久化數據,無需手動處理 localStorage 的讀寫。
也因為這樣我們可以經由 pinia
可以實現永久儲存的狀況,
import { computed, readonly } from "vue"
import { defineStore, acceptHMRUpdate } from "pinia";
import { useLocalStorage } from "@vueuse/core";
export const useBaseStore = defineStore("useBaseStore", () => {
// state::
const count = useLocalStorage('count', 0);
// getter::
const doubleCount = computed<number>(() => {
return count.value * 2;
});
// methods::
const increment = (): void => {
count.value++;
};
return {
// state::
count: readonly(count),
// getters::
doubleCount,
//methods::
increment
}
})
if (import.meta.hot) {
import.meta.hot.accept(acceptHMRUpdate(useBaseStore, import.meta.hot));
}
這個例子,可以讓我們即便重整頁面,狀態仍然都可以存在
useWindowSize
import { useWindowSize } from '@vueuse/core'
const { width, height } = useWindowSize();
useWindowSize
幫助我們輕鬆獲取並響應窗口大小的變化,非常適合用於響應式設計。
注意
:因為 useWindowSize
或是 useMouse
會偵測滑鼠指標或是螢幕變化,所以假設有兩三個不同的 component 使用 useWindowSize,那就會浪費多餘的資源和監控 window size 的變化。
vueuse 也提供了 createSharedComposable
方法針對資源調用可以進行一些優化。
<script setup lang="ts">
import { createSharedComposable, useWindowSize } from '@vueuse/core';
const useSharableWindowSize = createSharedComposable(useWindowSize);
const { width, height } = useSharableWindowSize();
</script>
<template>
<div data-testid="area" rounded-lg px-3 py-2 border="solid 1px red-500" bg-red-200>
寬:{{ width }} 高:{{ height }}
</div>
</template>
以上例子即便在其他地方使用 useWindowSize
只會進行一次性的監控,但狀態可以在不同地方享有。
除了使用現成的工具,創建自定義的 Composables 也是提升開發效率的重要方式。
讓我們創建一個 useApiFetch
Composable,用於處理 API 請求並且封裝 zod
去驗證回傳資料和錯誤:
import type { AfterFetchContext, BeforeFetchContext, OnFetchErrorContext } from '@vueuse/core';
import { createFetch, useLocalStorage } from '@vueuse/core';
import type { MaybeRef } from 'vue';
import { toValue } from 'vue';
import * as zod from 'zod';
export type RequestInput = string | number | boolean | File;
export type RequestInputs = RequestInput | RequestInput[];
export type RequestDataStructureInputs = RequestInputs | Record<string, RequestInputs> | Record<string, RequestInputs>[];
export type RequestJsonInputs = Record<string, RequestDataStructureInputs> | Record<string, RequestDataStructureInputs>[];
export interface CustomFetchErrorCtx {
data: unknown
response: Response | null
error: string;
}
export interface UseCustomFetchOptions {
isBearerTokenRequired?: boolean
query?: MaybeRef<Record<string, RequestInputs>>
json?: MaybeRef<RequestJsonInputs>
formData?: MaybeRef<Record<string, RequestInputs>>
responseSchema?: zod.ZodTypeAny
errorResponseSchema?: zod.ZodTypeAny
}
export type UseCustomFetchOptionsKey = UseCustomFetchOptions[keyof UseCustomFetchOptions];
export const useApiFetch = () => {
const useApi = (options: UseCustomFetchOptions) =>
createFetch({
baseUrl: `${import.meta.env.VITE_APP_API ?? ''}`,
options: {
timeout: 30000,
immediate: false,
beforeFetch: getBeforeFetch(options),
afterFetch: getAfterFetch(options),
onFetchError: getOnFetchError(options)
},
fetchOptions: {
mode: 'cors'
}
});
const getBeforeFetch = (options: UseCustomFetchOptions): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
const { isBearerTokenRequired, query, json, formData } = options;
return (ctx: BeforeFetchContext) =>
fetchCurryFn<BeforeFetchContext>(ctx, [
getAuthorizationBeforeFetch(isBearerTokenRequired),
getQueryBeforeFetch(query),
getJsonFormatBeforeFetch(json),
getFormDataFormatBeforeFetch(formData),
]);
};
const fetchCurryFn = <T extends BeforeFetchContext | AfterFetchContext | CustomFetchErrorCtx>(
ctx: T,
fnList: ((ctx: T) => T)[]
): T => fnList.reduce((acc, fn) => fn(acc), ctx);
const getAuthorizationBeforeFetch = (isTokenRequired: boolean = false): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
const token = useLocalStorage('token', '');
if (!isTokenRequired)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext) => {
if (!token) {
ctx.cancel();
return ctx;
}
ctx.options.headers = {
...ctx.options.headers,
Authorization: `Bearer ${token}`
};
return ctx;
};
};
const getQueryBeforeFetch = <T extends string | number | boolean | File>(
query?: MaybeRef<Record<string, T | T[]>>
): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
if (!query)
return noActionContext<BeforeFetchContext>;
const currentQuery = toValue(query);
if (!currentQuery)
return noActionContext<BeforeFetchContext>;
if (Object.keys(currentQuery).length === 0)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext): BeforeFetchContext => {
ctx.url += `?${generateQueryString(currentQuery)}`;
return ctx;
};
};
const generateQueryString = <T extends string | number | boolean | File>(queryData: Record<string, T | T[]>): string => {
const query = new URLSearchParams();
for (const [key, value] of Object.entries(queryData)) {
if (Array.isArray(value)) {
value.forEach(el => query.append(key, el.toString()));
continue;
}
if (checkIsNotEmpty(value)) {
query.append(key, value.toString());
}
}
const queryString = query.toString();
return queryString.length > 0 ? `${queryString}` : '';
};
const checkIsNotEmpty = (val: unknown) => {
if (typeof val === 'number' || typeof val === 'boolean')
return true;
return val !== '' && typeof val !== 'undefined';
};
const getJsonFormatBeforeFetch = (
jsonInput?: MaybeRef<RequestJsonInputs>
): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
if (!jsonInput)
return noActionContext<BeforeFetchContext>;
const currentRawData = toValue(jsonInput);
if (!currentRawData)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext): BeforeFetchContext => {
ctx.options.headers = {
...ctx.options.headers,
'Content-Type': 'application/json'
};
ctx.options.body = JSON.stringify(removeNullishInRecursiveObject(currentRawData));
return ctx;
};
};
const removeNullishInRecursiveObject = (obj: RequestJsonInputs): RequestJsonInputs => {
if (Array.isArray(obj)) {
return obj.map(el => removeNullishInRecursiveObject(el)) as RequestJsonInputs;
}
// if obj is boolean or number
if (isAllowBooleanNumberString(obj))
return obj;
// if obj is object but not array
const entries = Object.entries(obj)
.filter(([, v]) => {
if (!isAllowBooleanNumberAndObject)
return isNotEmpty(v);
return true;
})
.map(([k, v]) => {
if (Array.isArray(v)) {
return [
k,
v.filter(el => isNotEmptyExcludeEmptyString(el)).map(el => removeNullishInRecursiveObject(el as RequestJsonInputs))
];
}
if (isFile(v))
return [k, v];
if (typeof v === 'object') {
return [k, removeNullishInRecursiveObject(v)];
}
return [k, v];
});
return Object.fromEntries(entries);
};
const isNotEmpty = (v: unknown): boolean => {
return v !== '' && isNotEmptyExcludeEmptyString(v);
};
const isNotEmptyExcludeEmptyString = (v: unknown): boolean => {
return v !== undefined && v !== null;
};
const isAllowBooleanNumber = (v: unknown): boolean => {
if (typeof v === 'boolean')
return true;
return typeof v === 'number';
};
const isAllowBooleanNumberString = (v: unknown): boolean => {
return typeof v === 'string' || isAllowBooleanNumber(v);
};
const isAllowBooleanNumberAndObject = (v: unknown): boolean => {
if (isAllowBooleanNumber(v))
return true;
if (typeof v === 'object')
return true;
return false;
};
const getFormDataFormatBeforeFetch = <T extends string | number | boolean | File>(
formDataInput?: MaybeRef<Record<string, T | T[]>>
): ((ctx: BeforeFetchContext) => BeforeFetchContext) => {
if (!formDataInput)
return noActionContext<BeforeFetchContext>;
const currentRawData = toValue(formDataInput);
if (!currentRawData)
return noActionContext<BeforeFetchContext>;
return (ctx: BeforeFetchContext): BeforeFetchContext => {
ctx.options.body = convertObjectToFormData(currentRawData);
return ctx;
};
};
const isFile = (input: unknown): input is File => {
return input instanceof File;
};
const convertFormDataResult = <T extends string | number | boolean | File>(input: T): File | string => {
if (isFile(input))
return input;
return input.toString();
};
const convertObjectToFormData = <T extends string | number | boolean | File>(obj: Record<string, T | T[]>): FormData => {
const formData = new FormData();
for (const [key, value] of Object.entries(obj)) {
if (Array.isArray(value)) {
value.forEach(el => formData.append(key, convertFormDataResult(el)));
continue;
}
if (value === null || typeof value === 'undefined') {
continue;
}
formData.append(key, convertFormDataResult(value));
}
return formData;
};
const getAfterFetch = (options: UseCustomFetchOptions): ((ctx: AfterFetchContext) => AfterFetchContext) => {
const { responseSchema, errorResponseSchema } = options;
return (ctx: AfterFetchContext) =>
fetchCurryFn<AfterFetchContext>(ctx, [
responseSchemaAfterFetch(responseSchema),
errorSchemaAfterFetch(errorResponseSchema)
]);
};
const responseSchemaAfterFetch = (responseSchema?: zod.ZodTypeAny): ((ctx: AfterFetchContext) => AfterFetchContext) => {
if (!responseSchema)
return noActionContext<AfterFetchContext>;
return (ctx: AfterFetchContext) => {
if (!ctx.response.ok)
return ctx;
const validatedResponse = responseSchema.safeParse(ctx.data);
if (!validatedResponse.success) {
if (import.meta.env.MODE !== 'production') {
console.group(`%c ${ctx.response.url} [api response] type error`, 'color: yellow;');
console.log(validatedResponse);
console.groupEnd();
}
}
return ctx;
};
};
const errorSchemaAfterFetch = (errorResponseSchema?: zod.ZodTypeAny): ((ctx: AfterFetchContext) => AfterFetchContext) => {
if (!errorResponseSchema)
return noActionContext<AfterFetchContext>;
return (ctx: AfterFetchContext) => {
if (ctx.response.ok)
return ctx;
const validatedError = errorResponseSchema.safeParse(ctx.data);
if (!validatedError.success) {
if (import.meta.env.MODE !== 'production') {
console.group('%c [api error] type error', 'color: yellow;');
console.log(validatedError.error);
console.groupEnd();
}
throw new TypeError('型別錯誤');
}
return ctx;
};
};
const getOnFetchError = (
options: UseCustomFetchOptions
): ((ctx: CustomFetchErrorCtx) => Promise<Partial<OnFetchErrorContext>> | Partial<OnFetchErrorContext>) => {
const { } = options;
return (ctx: CustomFetchErrorCtx) =>
fetchCurryFn<CustomFetchErrorCtx>(ctx, [
typeErrorOnFetchError(), // 這裡可以持續擴展
]);
};
const typeErrorOnFetchError = (): ((ctx: CustomFetchErrorCtx) => CustomFetchErrorCtx) => {
return (ctx: CustomFetchErrorCtx) => {
if (ctx.error === 'someError') {
// type error do something
return ctx;
}
return ctx;
};
};
const noActionContext = <T extends BeforeFetchContext | AfterFetchContext | CustomFetchErrorCtx>(ctx: T): T => ctx;
return {
useApi
};
};
export type UseApiFetch = typeof useApiFetch;
現在,讓我們在組件中使用這個自定義的 Composable:
const mySampleApi = () => {
const responseSchema = zod.object({
message: zod.string()
});
return useApi({
responseSchema
})<zod.infer<typeof responseSchema>>('/api/hello')
.post()
.json<zod.infer<typeof responseSchema>>();
};
這個例子展示了如何使用我們的自定義 Composable 來處理 API 請求,包括加載狀態和錯誤處理。
並且針對回傳格式用 zod 進行驗證。
最後,讓我們看一個結合 @vueuse/core
和自定義 Composables 的高級例子:
import { useBreakpoints, useColorMode} from '@vueuse/core';
import { useApiFetch } from '../composables/useApiFetch';
import * as zod from 'zod';
const { useApi } = useApiFetch()
// 這裡展示 vuesue 的 顏色 mode
const colorMode = useColorMode()
// 還可以針對斷點做處理
const breakpoints = useBreakpoints({
mobile: 640,
tablet: 768,
desktop: 1024,
})
const isMobile = breakpoints.smaller('tablet') // 判斷是否是手機
// 這裡展示怎使用 createFetch 自定義的 api 使用
const mySampleApi = () => {
const responseSchema = zod.object({
message: zod.string()
});
return useApi({
responseSchema
})<zod.infer<typeof responseSchema>>('/api/hello')
.post()
.json<zod.infer<typeof responseSchema>>();
};
// 這裡的 data 已經經過 zod 驗證, 只要呼叫 execute 即可直接 call api
const { data, execute, error } = mySampleApi();
這個例子展示了如何結合使用 @vueuse/core
的功能(斷點處理和顏色模式)與自定義的 API 處理 Composable,創建一個響應式的、支持暗黑模式的數據展示組件。
通過運用 @vueuse/core
和創建自定義 Composables,我們可以顯著提高 Vue 3 和 TypeScript 的開發效率。@vueuse/core
為我們提供了大量即用的工具,幫助我們快速實現常見功能。而自定義 Composables 則允許我們根據項目需求,將業務邏輯封裝成可復用的單元。
結合這兩種方法,我們可以創建出更加模塊化、可維護、和高效的 Vue 3 應用。記住,好的工具和模式可以大大提升我們的開發體驗和效率,但關鍵是要根據實際需求靈活運用。持續學習和實踐這些技巧,將使您在 Vue 3 和 TypeScript 的開發之路上走得更遠、更快。